Skip to content

fix(code-block)!: errors following a full review of the component (#DS-5482) - #1988

Open
artembelik wants to merge 10 commits into
mainfrom
fix/code-block-signals
Open

fix(code-block)!: errors following a full review of the component (#DS-5482)#1988
artembelik wants to merge 10 commits into
mainfrom
fix/code-block-signals

Conversation

@artembelik

@artembelik artembelik commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

A full review of code-block, in the same shape as the 21.0.0 component reviews.

maxHeight lied about being required

readonly maxHeight = input<number, unknown>(undefined!, { transform: numberAttribute });

A code block with no [maxHeight] binding reported undefined from a non-nullable type: an assignment to a number held undefined, and maxHeight() > 0 was a NaN comparison that never matched. It reports number | undefined now — the same pathology, and the same fix, as KbqSplitButton.disabled in the first review wave.

KbqCodeBlockHighlight.file was write-only

@Input({ required: true })
set file(file: KbqCodeBlockFile) {  this.highlight(file); }

A required input with no getter, whose setter kicked off highlighting as a side effect — which is exactly why the automated signal migration skipped it. It is input.required() driven by an effect now, so it can finally be read.

The max-height binding never re-evaluated on its own

calculatedMaxHeight was a getter read from [style.max-height.px], so it only recomputed when something else marked the view dirty. It is a computed over maxHeight and viewAll now.

That binding turned out to have no test coverage at all — the new test caught a mistake I made in the very same change (binding the computed without calling it), which is worth knowing about the component.

What deliberately stays an accessor input

softWrap, viewAll, canDownload, files, activeFileIndex and hideTabs are WritableSignals over a @docs-private backing input() that carries the transform and aliases the public attribute name. A model() would have been the obvious shape and does not fit: ModelOptions carries no transform, and without one a valueless attribute passes the empty string and silently turns the feature off — <kbq-code-block softWrap> is in e2e.ts under a screenshot baseline and <kbq-code-block canDownload /> is in the published docs in both languages.

Template bindings are untouched[softWrap], [(viewAll)] and the valueless form all keep working. What changes is programmatic access: a read becomes a call and a write becomes .set(…). The deprecated canLoad and codeFiles stop writing into their replacements and fill in for them instead, so which of each pair wins no longer depends on attribute order.

Migration

code-block-signals runs from ng update @koobiq/components@21 (registered for 21.0.0-0). It rewrites reads to calls and plain writes to .set(…) on the six signal members, in TypeScript and through template reference variables on <kbq-code-block>. It reports what has no mechanical translation: compound assignments and increments, the backing inputs, the deprecated canLoad / codeFiles aliases, maxHeight — which reports number | undefined and never NaN now — and KbqCodeBlockHighlight.file, which was a write-only setter and is a readable required input.

Documented in docs/guides/migration.{en,ru}.md, section 18.

Testing

  • code-block.spec.ts: 50 → 66 tests. New coverage for the valueless boolean attribute, the deprecated canLoad / codeFiles fallbacks, an out-of-range and a non-numeric activeFileIndex, an empty files, a maxHeight that arrives after init, a failed highlight.js load, and the tab bar coming back once the files are named.
  • code-block-signals/index.spec.ts: 13 tests.
  • Full packages/components (4996 tests) and packages/schematics (438 tests) suites pass.
  • check-api is in sync.

BREAKING CHANGE

🤖 Generated with Claude Code

`maxHeight` was published as `InputSignal<number>` over an `undefined!` default,
so a code block with no `[maxHeight]` binding reported `undefined` from a
non-nullable type — an assignment to a `number` held `undefined`, and any
arithmetic on it produced NaN. It reports `number | undefined` now.

`KbqCodeBlockHighlight.file` was a write-only required input: a setter with no
getter that kicked off highlighting as a side effect. It is a required signal
input driven by an effect now, so it can finally be read.

The `max-height` applied while `viewAll` is off is a `computed`. It was a getter
read from a `[style.max-height.px]` binding, so it only re-evaluated when
something else marked the view dirty.

`softWrap`, `viewAll`, `canDownload`, `activeFileIndex` and `files` are backed by
signals. They stay accessor inputs with the same types and the same two-way
outputs: the component writes them as well as the binding, and a `model()` cannot
carry the `booleanAttribute` / `numberAttribute` transform they need.

BREAKING CHANGE: `KbqCodeBlock.maxHeight` reports `number | undefined` instead of
`number`, and `KbqCodeBlockHighlight.file` is a readable required signal input
rather than a write-only setter. Reported by the
`code-block-optional-max-height` schematic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added bug Something isn't working breaking changes labels Sep 3, 2026
`files.length < activeFileIndex` left the first out-of-range index in place:
three files with an active index of 2, shrunk to two, kept index 2 and the
template rendered `files[2].content` from undefined. The new test fails against
the old comparison and passes against `<=`.

The review's own narrowing fix hoisted the `maxHeight` read out of
`checkOverflow`, freezing the threshold at `ngAfterViewInit` so the resize
observer compared against a stale value. The read is back inside the callback.

`KbqCodeBlockHighlight` started a second highlight.js import for any `file` that
arrived while the first was still loading, registering the line-numbers plugin
twice, and neither continuation was cancelled on teardown. Deduped behind
`loadOnce()` with an `onCleanup` guard, and the promise is cleared on failure so
a later file change can still retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lskramarov lskramarov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — max effort

15 findings, most severe first. Everything below was reproduced by running code in a worktree at 21a06a93 and, where the question was "is this new?", re-run against the merge base 4bb1c9927 — each comment says which.

Headline: the migration is registered at 20.3.0-0, which ng update will never select now that 20.3.0 is tagged and main has moved every sibling to 21.0.0-0. That is the semantic conflict behind the textual one in migrations.json.

Two behaviour changes look like regressions against the merge base:

  1. set files now emits activeFileIndexChange and calls scrollTo() from inside an input setter — a parent that shrinks files and moves [(activeFileIndex)] in the same tick gets the wrong file and has its own field silently overwritten (NG0100 in the other binding order).
  2. The highlight effect's dependency set is history-dependent: startFrom/singleLine are dead until a file change routes through the synchronous branch, then live forever.

The rest split into incomplete fixes in touched code (setupContentOverflowDetection never re-arms; set activeFileIndex has no clamp; empty files still crashes; hideTabs still latches; maxHeight can still be NaN), one altitude call (loadOnce() is scoped to the directive while initLineNumbersPlugin is global), and notes on the guide, the schematic's matching, and two of the new tests.

Verified clean, for the record: check-api passes for components/code-block after a real build, the any/unknown ratchet is unchanged, prettier/eslint/cspell pass on every changed file, the Jest suite is 55/55, effects still flush before ngAfterViewInit so the pending contract is preserved, the model()-cannot-carry-a-transform justification in the code comment is correct, and no maxHeight() or .file = call site in this repo breaks on the new types.

Generated with Claude Code

Comment thread packages/schematics/src/migrations.json Outdated
Comment thread packages/components/code-block/code-block.ts Outdated
Comment thread packages/components/code-block/code-block.ts Outdated
Comment thread packages/components/code-block/code-block-highlight.ts Outdated
Comment thread packages/components/code-block/code-block.ts Outdated
Comment thread packages/components/code-block/code-block.ts Outdated
Comment thread docs/guides/migration.en.md Outdated
Comment thread packages/schematics/src/migrations/code-block-optional-max-height/index.ts Outdated
Comment thread packages/components/code-block/code-block.spec.ts Outdated
Comment thread packages/components/code-block/code-block.spec.ts Outdated
artembelik and others added 2 commits September 10, 2026 10:38
`set files` no longer emits or scrolls. Resetting the index from inside an input setter wrote
`activeFileIndexChange` back into a `[(activeFileIndex)]` while the parent was still updating, so a
parent that shrank `files` and moved the index in one tick got the wrong file and a silently
rewritten field - `NG0100` in the other binding order. The render clamps instead: `renderedFileIndex`
falls back to the first file, `activeFile` reports `undefined` for an empty list, and the template
renders nothing rather than reaching through `files[activeFileIndex]`. `<kbq-code-block />` and
`[files]="[]"` used to throw.

`activeFileIndex` also stopped accepting NaN, which `numberAttribute` produces for an unbound
`index?: number` and which walked past every range guard behind a type saying `number`. `maxHeight`
gets the same treatment at its transform, so `<kbq-code-block maxHeight>` and `[maxHeight]="'200px'"`
report `undefined` instead of NaN - the type promised `number | undefined` and delivered NaN.

The overflow gate ran once from `ngAfterViewInit`, so a `maxHeight` bound later clipped the content
with neither the "view all" button nor a way in from the keyboard, and a raised limit left the button
showing. It follows `maxHeight` now, and the layout reads sit behind the memoized `calculatedMaxHeight`
check that would have skipped them.

In the highlight directive a failed load un-latched `hljsLoading` but not `_pending`, so `pending()`
stayed true for the life of the page and everything waiting on it - `scrollTo`, the overflow gate -
stalled, while the rejection escaped as an uncaught error on every attempt. The load reports its
failure instead of throwing it. The effect's dependency set no longer depends on which branch ran
first: `startFrom` and `singleLine` are captured where the effect can see them. And the line-numbers
plugin installs its `<style>` and its document-level `copy` listener once per hljs instance rather
than once per directive - neither is ever removed.

The schematic matched `\bKbqCodeBlock\b` over `.ts` only, which is the one shape a real call site does
not have: a read through `#block="kbqCodeBlock"` lives in markup, in a file that names the module.
Templates are visited, the anchors cover the module, the element and the exportAs, and patterns are
matched against source with comments and string literals blanked out.

Both guides and the schematic's report claimed the five signal-backed inputs "stay accessor inputs";
four of them were plain public fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit 9829146):

https://koobiq-next--prs-1988-0dw291hj.web.app

(expires Sun, 13 Sep 2026 13:56:17 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: c9e37e518febda70d0317d07e8ceb35ac43c534c

artembelik and others added 2 commits September 10, 2026 12:11
`hideTabs` is derived instead of written. A single file with no filename still hides the tab bar -
the rule the input has always documented - but the component no longer writes `true` into its own
input to get there. The write latched the bar off for the life of the component, so naming the files
later never brought it back and every file past the first stayed unreachable, and it re-emitted
`hideTabsChange` on every `files` assignment even when nothing had changed.

`[hideTabs]="false"` no longer shows the bar for a lone unnamed file. That case only ever worked
because the consumer's template happened to list `[files]` before `[hideTabs]`: with the attributes
the other way round the same code hid the tabs, so what the old test pinned was attribute order
rather than a contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`KbqCodeBlock` has no `@Input` decorators left. `softWrap`, `viewAll`, `canDownload`, `files`,
`activeFileIndex` and `hideTabs` are `WritableSignal`s over a `protected` backing `input()` that
carries the `booleanAttribute` / `numberAttribute` transform and aliases the public attribute name.

`model()` would have been the obvious shape, and it does not fit: `ModelOptions` in 20.3.30 is
`{ alias, debugName }` with no `transform`, and without one a valueless attribute passes the empty
string and silently turns the feature off. `<kbq-code-block softWrap>` is in `e2e.ts` under a
screenshot baseline and `<kbq-code-block canDownload />` is in the published docs in both languages,
so that is not a theoretical shape. The backing input keeps the transform; a `linkedSignal` over it
keeps the half the component writes. `private` is not available for this - the compiler rejects it
with NG1053 - so the backing inputs are `protected` and `@docs-private`.

The two deprecated aliases stop writing into their replacements: either `canLoad` or `canDownload`
turns the download button on, and `codeFiles` applies while `files` is empty. Which of each pair won
used to depend on the order they sat in the consumer's template.

The migration is renamed `code-block-signals`, because it now rewrites rather than only reports: a
read becomes a call and a plain write becomes `.set(…)`, in TypeScript and through template reference
variables on `<kbq-code-block>`. Compound assignments and increments are reported instead - they
would need the receiver spelled twice - as are the backing inputs and the two deprecated aliases,
which cannot be written at all any more. The seeded copy of the shared machinery also carries the
`!x.member` misclassification the sibling migrations have, fixed here.

`check-public-api-any` records code-block at 15: six `InputSignalWithTransform<T, unknown>` replaced
six `ngAcceptInputType_*`, and only the former are counted. The `unknown` is Angular's own parameter
for what the transform accepts; narrowing it would reject valid bindings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🚨 E2E tests failed

Review the report for details.


💡 Comment /approve-snapshots to approve snapshot changes.

The reseeded migration lost two reports the schematic it replaced emitted: `maxHeight` reads and
`KbqCodeBlockHighlight.file` writes matched neither member set, so the only mention left was a line
in the once-per-project summary with no file attached - while the guide still tells consumers to fix
exactly those call sites. Both are reported per file again, and the receiver pass resolves the
highlight directive as a type of its own.

`WRITABLE_MEMBERS` aliased `SIGNAL_MEMBERS`, so the flag threaded through `classifyAccess` was always
true; the set and the parameter are gone.

`onSelectedTabChange` compared the clicked index against the rendered one, so clicking the tab that
is already on screen left an out-of-range `activeFileIndex` in place - and a later `files` that grew
past it would jump there with no user action. It compares against the bound index instead.

The overflow gate read `pending()` in the same effect that owns the ResizeObserver subscription, so
every highlight tore the subscription down and rebuilt it around two forced layout reads. The
subscription follows the element, a second effect retakes the measurement when highlighting settles.

Tests for the paths the refactor changed and nothing covered: a valueless boolean attribute, the
deprecated `canLoad` turning the download button on, and `codeFiles` filling in while `files` is
empty - the shape `docs-live-example-viewer` binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🚨 E2E tests failed

Review the report for details.


💡 Comment /approve-snapshots to approve snapshot changes.

The backing inputs were `protected`, which compiles inside the library and breaks every consumer:
with `strictTemplates` a `[softWrap]` binding is checked against the class member the alias points at,
so `docs-examples` failed with `TS2445: Property 'softWrapInput' is protected`. `ng build components`
never catches it - the component's own template does not bind its own inputs - and `ts-jest` does not
type-check templates at all, so the local run was green. `ng build docs-examples` is the check that
reproduces it, and `tree/toggle.ts` keeps its aliased `disabledInput` public for the same reason.

`PROTECTED_MEMBERS` in the migration is renamed `PLUMBING_MEMBERS`: the members it lists are public
now, and its report says what they actually are - read-only inputs that carry the attribute and its
transform, and report what was bound rather than what the component holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@artembelik artembelik self-assigned this Sep 10, 2026
@artembelik
artembelik marked this pull request as ready for review September 10, 2026 12:16
Copilot AI lite review requested due to automatic review settings September 10, 2026 12:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

It introduces new public API members that publish additional unknown (triggering a baseline bump against the repo’s check-public-api-any ratchet) and has a couple of fixable documentation/PR-description inconsistencies.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR completes a “full review” of code-block, finalizing its signal-based API, fixing several runtime edge cases (notably around maxHeight, hideTabs, and empty/out-of-range file selection), and adding an ng update migration (code-block-signals) plus documentation updates.

Changes:

  • Refactors KbqCodeBlock inputs/state to signals (backing inputs + linkedSignal), fixes maxHeight semantics, and makes max-height styling reactive via computed.
  • Refactors KbqCodeBlockHighlight.file into a readable required signal input and hardens highlight.js loading (single load, no latched pending, one-time plugin install).
  • Adds a code-block-signals migration schematic (+ tests) and documents the migration in both EN/RU guides.
File summaries
File Description
tools/public_api_guard/components/code-block.api.md Updates public API snapshot for the new signal-based surface (new signal members and backing inputs).
tools/check-public-api-any/baseline.json Updates the recorded any/unknown count for code-block.
packages/schematics/src/migrations/code-block-signals/schema.ts Adds TypeScript schema interface for the new migration.
packages/schematics/src/migrations/code-block-signals/schema.json Adds schematic JSON schema for options (project, fix).
packages/schematics/src/migrations/code-block-signals/README.md Documents what the migration rewrites/reports and how to run it.
packages/schematics/src/migrations/code-block-signals/index.ts Implements AST/template rewriting for signal reads/writes and reporting for non-mechanical cases.
packages/schematics/src/migrations/code-block-signals/index.spec.ts Adds schematic tests covering TS and template rewrites, reporting, idempotency, and fix=false.
packages/schematics/src/migrations/code-block-signals/data.ts Centralizes member lists and user-facing report messages for the migration.
packages/schematics/src/migrations.json Registers the code-block-signals migration for version 21.0.0-0.
packages/schematics/src/collection.json Exposes the new migration in the schematics collection with schema.
packages/components/code-block/code-block.ts Core refactor: signal-based inputs/state, reactive max-height, derived tab hiding, safer file access, overflow gate fixes.
packages/components/code-block/code-block.spec.ts Adds unit coverage for unbound/invalid maxHeight, reactive max-height behavior, highlight file() readability, overflow gate, and edge cases.
packages/components/code-block/code-block.html Updates template to call signals, use derived tabsHidden(), and safely render only when an active file exists.
packages/components/code-block/code-block-highlight.ts Makes file a required signal input (readable), adds effect-driven highlighting, and robust/single highlight.js load behavior.
docs/guides/migration.ru.md Documents the new code-block-signals migration and behavioral changes (RU).
docs/guides/migration.en.md Documents the new code-block-signals migration and behavioral changes (EN).
Review details

Suppressed comments (5)

packages/components/code-block/code-block.ts:200

  • viewAllInput currently infers its transform input type as unknown, which becomes part of the published API and is tracked by check-public-api-any. Narrowing the second generic to the concrete types booleanAttribute is intended to accept would avoid introducing another unknown into the public surface.
    readonly viewAllInput = input(false, { alias: 'viewAll', transform: booleanAttribute });

packages/components/code-block/code-block.ts:240

  • canLoadInput (new backing input) is currently exposed as InputSignalWithTransform<boolean, unknown> in the public API. Since the repo has a ratchet against growing any/unknown in the published type surface, please narrow the transform input type parameter to the actual template types booleanAttribute coerces.
    readonly canLoadInput = input(false, { alias: 'canLoad', transform: booleanAttribute });

packages/components/code-block/code-block.ts:247

  • Same as canLoadInput: canDownloadInput currently publishes unknown as the transform input type. Narrowing the second generic would keep the public surface typed without adding new unknown occurrences.
    readonly canDownloadInput = input(false, { alias: 'canDownload', transform: booleanAttribute });

packages/components/code-block/code-block.ts:297

  • activeFileIndexInput is a newly introduced public backing input and will be emitted as InputSignalWithTransform<number, unknown> in the generated typings. To avoid growing the package’s unknown count (tracked by check-public-api-any), consider narrowing the transform input type to what numberAttribute accepts from templates (string/number/null/undefined).
    readonly activeFileIndexInput = input(0, {

packages/components/code-block/code-block.ts:344

  • hideTabsInput is another new backing input that currently leaks unknown into the public API surface via InputSignalWithTransform<boolean, unknown>. Please consider narrowing the transform input type parameter to keep the type surface within the repo’s any/unknown ratchet expectations.
    readonly hideTabsInput = input(false, { alias: 'hideTabs', transform: booleanAttribute });
  • Files reviewed: 16/16 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/components/code-block/code-block.ts
Comment thread packages/schematics/src/migrations.json
Comment thread packages/schematics/src/migrations/code-block-signals/README.md Outdated
The "What it does not do" heading had the paragraph glued onto it and the rest orphaned after a blank
line. The section also lost the sentence about what is reported rather than rewritten: the edit that
was supposed to add it used a plain `String.replace` whose pattern no longer matched after an earlier
edit, so it silently did nothing. Heading and paragraph restored together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lskramarov
lskramarov requested a review from NikGurev September 10, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking changes bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants